Skip to content

fix: return 405 for pre-session GET the server won't serve as SSE - #3129

Open
dosvk wants to merge 4 commits into
modelcontextprotocol:mainfrom
dosvk:fix/3102-get-405-spec-compliance
Open

fix: return 405 for pre-session GET the server won't serve as SSE#3129
dosvk wants to merge 4 commits into
modelcontextprotocol:mainfrom
dosvk:fix/3102-get-405-spec-compliance

Conversation

@dosvk

@dosvk dosvk commented Jul 18, 2026

Copy link
Copy Markdown

Closes #3102

Summary

Per the Streamable HTTP transport spec (Listening for Messages from the Server):

The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint.

In stateful mode, a pre-session GET (no mcp-session-id header) returned 400 Bad Request: Missing session ID instead. Since client transports that probe for a standalone SSE stream before initialize treat only 405 as the graceful "no SSE — fall back to POST" signal (e.g. the TypeScript SDK's _startOrAuthSse), any other status aborts the handshake — the interop break described in #3102 (credit to @fgranata for the thorough root-cause analysis).

The 406-for-wildcard-Accept arm of the report is already fixed on main via check_accept_headers; this PR addresses the remaining 400 path.

Changes

  • _handle_get_request: a session-less GET in stateful mode now returns 405 with Allow: GET, POST, DELETE (matching _handle_unsupported_request), before Accept validation — the spec's requirement applies to any GET the server won't serve as SSE, regardless of Accept header.
  • New regression test covering the client-probe shapes (Accept: */*, application/json, absent, text/event-stream), asserting status, Allow header, and JSON-RPC error code.
  • Updated one existing test that asserted the old 400 behavior.

Backward compatibility

Post-session GETs (valid mcp-session-id + SSE Accept) are unchanged and still serve SSE. Stateless mode is unaffected. POST/DELETE session validation is unchanged.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/streamable_http.py
dosvk pushed a commit to dosvk/python-sdk that referenced this pull request Jul 18, 2026
…s created

A pre-session GET (one without a session ID header in stateful mode) was
being handled AFTER a transport and session were already registered. The
transport's _handle_get_request correctly returned 405, but by then an
unused session existed indefinitely (with default no idle timeout).

Restructured to reject pre-session GETs at the manager layer BEFORE any
transport creation:

1. Manager now checks for GET without session ID first
2. Security validation (DNS rebinding protection) runs before the 405
   so malformed/attack requests get their proper 421 response
3. Only after security passes does it return 405 Method Not Allowed

The transport-level check remains as defensive code for standalone
transport use (the class is public). Tests now cover both paths:
- Manager path: test_pre_session_get_rejected_without_creating_transport
- Standalone path: test_standalone_transport_pre_session_get_returns_405

Also added test_standalone_transport_get_with_wrong_session_returns_404
to cover the session ID mismatch validation (removed pragma: no cover).

Addresses review finding: modelcontextprotocol#3129
@dosvk

dosvk commented Jul 18, 2026

Copy link
Copy Markdown
Author

Good catch on the session leak — fixed in the latest commits.

The 405 rejection now happens in StreamableHTTPSessionManager._handle_stateful_request() before any transport is created or registered, so pre-session GET probes no longer leave a session behind (verified by test_pre_session_get_rejected_without_creating_transport, which asserts _server_instances stays empty). Security validation still runs first so DNS-rebinding rejections keep their 421.

The transport-level check remains as a defensive path for standalone (manager-less) transport use, now with its own tests instead of a pragma: no cover. Coverage on both changed files is back to 100%, and the snapshot assertions are collapsed per ruff format.

@dosvk

dosvk commented Jul 27, 2026

Copy link
Copy Markdown
Author

Hey — just checking in on this. CI has been green for a while now and @fgranata offered to test it against their production setup (they have the exact failing client transport on the other side). Let me know if anything needs adjusting.

Veerendra Kumar added 4 commits August 6, 2026 20:47
The Streamable HTTP spec requires a GET the server does not serve as an
SSE stream to get 405 Method Not Allowed, but in stateful mode a
pre-session GET returned 400 (missing session ID) instead. Only-405 is
what client transports (e.g. the TypeScript SDK's SSE probe) treat as
the graceful fall-through to POST, so stock servers aborted those
handshakes before initialize.

Return 405 with Allow: GET, POST, DELETE for session-less GETs in
stateful mode, before Accept validation, matching the Allow value of
_handle_unsupported_request. (The 406-for-wildcard-Accept arm of the
report is already fixed on main via check_accept_headers.)

Closes modelcontextprotocol#3102
…s created

A pre-session GET (one without a session ID header in stateful mode) was
being handled AFTER a transport and session were already registered. The
transport's _handle_get_request correctly returned 405, but by then an
unused session existed indefinitely (with default no idle timeout).

Restructured to reject pre-session GETs at the manager layer BEFORE any
transport creation:

1. Manager now checks for GET without session ID first
2. Security validation (DNS rebinding protection) runs before the 405
   so malformed/attack requests get their proper 421 response
3. Only after security passes does it return 405 Method Not Allowed

The transport-level check remains as defensive code for standalone
transport use (the class is public). Tests now cover both paths:
- Manager path: test_pre_session_get_rejected_without_creating_transport
- Standalone path: test_standalone_transport_pre_session_get_returns_405

Also added test_standalone_transport_get_with_wrong_session_returns_404
to cover the session ID mismatch validation (removed pragma: no cover).

Addresses review finding: modelcontextprotocol#3129
The 405 rejection paths return before the request body is read, so the
stub receive callables never execute. Matches the existing convention
for unreachable test helpers in this suite.
@dosvk
dosvk force-pushed the fix/3102-get-405-spec-compliance branch from deedfb8 to 5bd3ce6 Compare August 6, 2026 20:49
@fgranata

fgranata commented Aug 7, 2026

Copy link
Copy Markdown

Verification from our side as offered — results below.

Setup: StreamableHTTPSessionManager mounted under Starlette, served by uvicorn — the same transport class our production streamable-HTTP deployment runs (three public MCP hosts, real buyer-agent traffic). SDK installed from this PR's branch (mcp + mcp-types workspace packages).

Results (all as the PR intends):

Probe Expected Got
GET /mcp/ with Accept: text/event-stream, no session 405 405
GET /mcp/ with unknown mcp-session-id 404 404
POST /mcp/ with unknown mcp-session-id 404 404
Normal initialize POST 200 + session id 200 + mcp-session-id

This is exactly the behavior we currently provide via a pure-ASGI middleware shim in front of the SDK (built after a partner's client transport spun on the pre-session GET) — with this PR the shim becomes deletable, which is the outcome we were hoping for. 👍

One caveat on scope: we intended to verify with our full production application, but couldn't — the app runs FastMCP 3.4.4, which imports request_ctx from mcp.server.lowlevel.server; that symbol is gone on current main (unrelated to this PR — main's restructuring). So the verification above is transport-level (the code paths this PR changes), not whole-app. Once a release containing this fix is consumable through FastMCP, we'll drop our shim and the production deployment itself becomes the ongoing verification.

Thanks @dosvk — from our seat this is ready.

@dosvk

dosvk commented Aug 7, 2026

Copy link
Copy Markdown
Author

Thank you for running this so quickly — the probe table covers exactly the code paths this PR changes, and knowing it makes your middleware shim deletable is the best possible signal that the fix matches real-world need.

Noted on the FastMCP request_ctx import — agreed that's from main's restructuring and independent of this change, but it's useful context for anyone doing whole-app verification downstream.

For maintainers, current state: rebased onto main, four new tests covering the pre-session GET / unknown-session paths, and independent transport-level verification above from a production streamable-HTTP operator. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamable HTTP server never returns the spec-mandated 405 for GET requests it won't serve as SSE (406/400 instead) — breaks client SSE-probe fallback

2 participants